feat(mcp): rotate MCP OAuth refresh tokens with replay detection - #8273
feat(mcp): rotate MCP OAuth refresh tokens with replay detection#8273andypalmi wants to merge 27 commits into
Conversation
Add RFC 8414 authorization-server metadata and RFC 9728 protected-resource metadata under /.well-known so MCP clients can auto-discover the OAuth endpoints and the MCP resource URL. Public, license-tier independent. Ref #7431
…agents Extend the OAuth2 flow so external MCP agents (Claude, Cursor, etc.) can authenticate. Rather than a single hardcoded client id, agents register dynamically per RFC 7591: POST /account/client persists a public AuthClient (type 'mcp', no secret) with its approved redirect URIs and returns a generated client id. The authorize, complete, and token endpoints recognise these clients by looking them up, skip the project/device ownership checks (MCP is user-scoped), and drive an MCP consent step that records the read-only and team selection before issuing a scoped personal access token. Redirect URIs must be loopback http (RFC 8252, port-flexible) or https for hosted clients; token issuance and refresh require no client secret. Also forward the caller scope through the platform automation handler and add a platform_get_active_user tool that reports the calling token's scope. Adds AuthClient.type/name/redirectURIs (migration + model), an AuthClient.createMCPClient controller, and AccessToken.createMCPOAuthToken. Ref #7432
Add the AccessRequestMCP.vue consent page and its /account/request/:id/mcp modal route. The page lets the user pick read-only or full access and scope the grant to specific teams before approving the MCP agent request. Ref #7433
Move the RFC 9728 protected-resource document out of the root .well-known handler into the license-gated EE mcp plugin, so it is only advertised where the /mcp resource exists. Serve it at the path-inserted /.well-known/oauth-protected-resource/mcp (RFC 9728 3.1) with the bare path kept as an alias, and challenge unauthenticated /mcp requests with a WWW-Authenticate header pointing at that metadata.
An AccessToken row holds both the access token and its refresh token, and getOrExpire destroyed the row when the access token expired, taking the refresh token with it so a client could never refresh (RFC 6749 1.5). Add a separate refreshTokenExpiresAt lifetime: reject an expired access token but keep the row while its refresh token is still valid. The MCP refresh token is stable rather than rotating, and concurrent refreshes coalesce through a shared cache so they reuse the most recently minted access token instead of overwriting the row.
Capture the pre-refresh expiry after lowering it, so the assertion compares against the shortened lifetime rather than the original one and no longer ties when the refresh lands in the same millisecond.
Link the MCP OAuth token to the AuthClient it was issued to and name it after the registered client instead of the fixed 'MCP Agent', so the user's token list and later audit attribution can tell agents apart.
Rotate the refresh token on every MCP refresh instead of keeping it stable: each refresh issues a new refresh token and records the previous one. A rotated-out token presented within a short grace window returns the current tokens, so a concurrent or retried refresh still succeeds; presented after the window it is treated as a replay and the grant is revoked (RFC 9700 section 4.14.2). Concurrent refreshes are serialised with a compare-and-swap on the current refresh token: the update only matches while the token is still current, so exactly one refresh rotates and the loser returns the winner's tokens from the shared cache rather than a token that never reached the row.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feat/8271-mcp-audit-attribution #8273 +/- ##
==================================================================
Coverage ? 76.25%
==================================================================
Files ? 451
Lines ? 24175
Branches ? 6454
==================================================================
Hits ? 18435
Misses ? 5740
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The refresh route rejected any refresh token byRefreshToken could not find. For a rotated MCP token that is the normal case: its hash has moved to previousRefreshToken, so the guard short-circuited the grace window and replay detection in refreshToken() before either could run. Defer resolution to refreshToken() for MCP clients, which handles the current-or-previous token, the grace window, and replay revocation. Add a route-level test covering rotation, grace retry, and replay through /account/token.
Platform tool calls were always stamped source 'mcp:expert', so a third-party agent's actions rendered as the first-party Expert. The MCP door now records the caller's registered client name against its mcpSessionId, and the comms handler reads it to stamp source 'mcp' with that client name. The Expert path never opens that door, so it keeps the 'mcp:expert' default. The audit entry surfaces the client name. Closes #8271.
…resh-token-rotation
getOrExpire eager-loaded AuthClient on every token lookup so the client name could be attached to the session, adding a join to every Bearer request when only MCP tokens set AuthClientId. Drop the join and resolve the name with a targeted lookup that runs only when AuthClientId is set.
…resh-token-rotation
The refresh_token grant returned access_token, expires_in and refresh_token but omitted token_type, which RFC 6749 section 5.1 requires. A spec-compliant client rejects the response and falls back to re-authorization even though the server rotated the token successfully. Return token_type: bearer to match the authorization_code response.
…resh-token-rotation
…resh-token-rotation
|
it's unclear to me what we're fixing here |
|
It looks to me that we're treating a symptom before it presented it's effects; could this be postponed to 3.1? |
Run third-party MCP platform actions under the caller's own PAT rather than a freshly minted platform token, and derive the audit source from whether the session has one. The door stashes the PAT against the mcpSessionId; the comms layer injects it and marks the entry 'mcp', or mints a token and marks 'mcp:expert' for the first-party Expert path. Removes the per-client identity plumbing (AuthClientId column, client name lookup and rendering), which is not needed to distinguish expert, third-party and api sources.
…resh-token-rotation # Conflicts: # forge/db/models/AccessToken.js
|
Yes, let's postpone this. Agreed it is security hardening rather than something the OAuth flow needs to function, so I'll pull it out of the stack into its own PR and target it separately (3.1 works for me). For context on what it does: the base refresh flow (exchanging a valid refresh token for a new access token) already lands in #8264 and is complete on its own. This PR adds hardening on top: rotating the refresh token on each use, a compare-and-swap so two concurrent refreshes converge on one result, a short grace window for retries, and replay detection that revokes the grant if an already-rotated refresh token is presented again (RFC 9700 section 4.14.2). None of that is required for refresh to work, which is why it can land later without blocking the rest of the stack. |
…-token-rotation # Conflicts: # forge/db/controllers/AccessToken.js # forge/db/controllers/AuthClient.js # forge/db/migrations/20260824-01-add-mcp-authclient-fields.js # forge/db/models/AccessToken.js # forge/db/models/AuthClient.js # forge/routes/auth/oauth.js # test/unit/forge/db/controllers/AccessToken_spec.js # test/unit/forge/routes/auth/oauth_spec.js
Summary
Rotates the MCP OAuth refresh token on every refresh, with a grace window and replay detection, replacing the stable (non-rotating) refresh token.
/account/token. A route-level test now drives rotation, grace retry, and replay through the endpoint.Part of #7423. Closes #8270.